package com.monochromebytes.mononet.packet;

import java.io.IOException;
import java.net.DatagramSocket;
import java.net.InetSocketAddress;
import java.nio.channels.DatagramChannel;
import java.util.ArrayList;

/**
 * Represents an UDP socket on an IP address.
 * 
 * An UDPSocket is not bound to an UDP port and IP address until open() is
 * called. An opened UDPSocket is bound to an UDP socket and IP address unless
 * close() is not called or any IOException has been thrown.
 * 
 * An UDPSocket uses two Thread and worker objects. One of each for sending and
 * one for receiving packets. The outgoing packets will be fetched from the
 * Outbox assigned to the socket and received packets will be added to the
 * assigned Inbox.
 */
public class UDPSocket {

    /**
     * The worker object that receives packets from the channel.
     */
    private final UDPSocketReceiver         receiver;

    /**
     * The worker object that sends packets to the channel.
     */
    private final UDPSocketSender           sender;

    /**
     * The Inbox the worker adds the received packets to.
     */
    private final Inbox                     inbox;

    /**
     * The Outbox the worker fetches the outgoing packets from.
     */
    private final Outbox                    outbox;

    /**
     * The thread that runs the receiving worker.
     */
    private final Thread                    receiverThread;

    /**
     * The thread that runs the sending worker.
     */
    private final Thread                    senderThread;

    /**
     * The listeners that will be informed about opening and closing this
     * socket.
     */
    private final ArrayList<UDPSocketListener> listeners;

    /**
     * The channel that is used to send and receive packets. This variable is
     * null if the UDPSocket is not opened.
     */
    private DatagramChannel                 channel;

    /**
     * The address the UDPSocket is bound to. This variable is null if the
     * UDPSocket is not opened.
     */
    private InetSocketAddress               address;

    /**
     * Creates a new, not opened UDPSocket with the given Inbox and Outbox.
     * 
     * @param inbox
     *            The Inbox that will receive the incoming packets.
     * @param outbox
     *            The Outbox outgoing packets will be fetched from.
     */
    public UDPSocket(Inbox inbox, Outbox outbox) {
        this.inbox = inbox;
        this.outbox = outbox;
        this.receiver = new UDPSocketReceiver(this, inbox);
        this.sender = new UDPSocketSender(this, outbox);
        this.receiverThread = new Thread(this.receiver);
        this.senderThread = new Thread(this.sender);
        this.listeners = new ArrayList<UDPSocketListener>();
    }

    @Override
    protected void finalize() throws Throwable {
        // Take care to close the socket before deleteing the reference.
        this.close();
        super.finalize();
    }

    /**
     * Opens the socket and bind it to the given local address to listen on and
     * send methods.
     * 
     * If the socket has been opened correctly, its worker threads will be
     * started.
     * 
     * @param address
     *            The address the socket is bind to.
     * @throws IOException
     *             If an I/O Error occurs.
     */
    public synchronized void open(InetSocketAddress address) throws IOException {
        // Close the socket if currently opened.
        this.close();

        // Open it and bind it to the given address to listen on.
        try {
            this.channel = DatagramChannel.open();
            this.channel.configureBlocking(true);

            DatagramSocket socket = this.channel.socket();
            socket.bind(address);
            
            this.address = address;

            // Start receiver thread.
            if (!this.receiverThread.isAlive()) {
                this.receiverThread.setName("UDPSocketReceiver: " + address);
                this.receiverThread.start();
            }

            // Start sender thread.
            if (!this.senderThread.isAlive()) {
                this.senderThread.setName("UDPSocketSender: " + address);
                this.senderThread.start();
            }
            // Force a check for new packets.
            this.sender.outgoingPackedAdded();

            // Inform listeners.
            for (UDPSocketListener listener : this.listeners) {
                listener.socketOpened(this);
            }
        } catch (IOException e) {
            // Close the socket if any I/O Exception has occured.
            this.close(e);
            throw e;
        }
    }

    /**
     * Returns true if the socket is opened.
     * 
     * Note that a port can become closed at any time by the system.
     */
    public synchronized boolean isOpen() {
        if (this.channel == null) {
            return false;
        }

        if (!this.channel.isOpen()) {
            this.channel = null;
            return false;
        }
        return true;
    }

    /**
     * Closes the socket.
     * 
     * The worker threads will be notified to stop and the SocketListener
     * objects informed.
     * 
     * @param e
     *            The IOException that forces the socket to close.
     * @throws IOException
     *             If an I/O Error occurs.
     */
    protected synchronized void close(IOException e) throws IOException {
        try {
            this.senderThread.interrupt();
            this.receiverThread.interrupt();

            this.senderThread.join();
            this.receiverThread.join();
            
            if (this.channel != null) {
                this.channel.close();
            }
        } catch (IOException e2) {
            throw e2;
        } catch (InterruptedException e2) {
            // Nothing to do.
        } finally {
            this.channel = null;
            this.address = null;

            // Inform listeners.
            for (UDPSocketListener listener : this.listeners) {
                listener.socketClosed(this, e);
            }
        }
    }

    /**
     * Closes the socket.
     * 
     * The worker threads will be notified to stop and the SocketListener
     * objects informed.
     * 
     * @throws IOException
     *             If an I/O Error occurs.
     */
    public synchronized void close() throws IOException {
        this.close(null);
    }

    /**
     * Returns the Inbox the socket adds incoming packages to.
     * 
     * @return The Inbox the socket adds incoming packages to.
     */
    public Inbox getInbox() {
        return this.inbox;
    }

    /**
     * Returns the Outbox the socket fetches outgoing packages from.
     * 
     * @return The Outbox the socket fetches outgoing packages from.
     */
    public Outbox getOutbox() {
        return this.outbox;
    }

    /**
     * Returns the bound InetSocketAddress (IP with port) if the socket is open
     * or null if not.
     * 
     * @return The address the socket is bound to or null if it is not open.
     */
    public InetSocketAddress getAddress() {
        return this.address;
    }

    /**
     * Returns the used DatagramChannel if the socket is open or null if not.
     * 
     * @return The used DatagramChannel if the socket is open or null if not.
     */
    public DatagramChannel getChannel() {
        return this.channel;
    }

    /**
     * Adds a SocketListener to the list of listeners.
     * 
     * The listeners will be informed about opening and closing this socket.
     * 
     * @param listener
     *            The listener that will be added.
     */
    public void addSocketListener(UDPSocketListener listener) {
        this.listeners.add(listener);
    }

    /**
     * Removes a SocketListener from the list of listeners.
     * 
     * The listener won't be informed about opening and closing this socket, any
     * longer.
     * 
     * @param listener
     *            The listener that will be removed.
     */
    public void removeSocketListener(UDPSocketListener listener) {
        this.listeners.remove(listener);
    }
}
